<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Open-LLM-VTuber</title>
    <!-- <script src="https://cdn.jsdelivr.net/npm/pixi.js@6.5.2/dist/browser/pixi.min.js"></script> -->
    <script src="libs/pixi.min.js"></script>
    <!-- <script src="https://cubism.live2d.com/sdk-web/cubismcore/live2dcubismcore.min.js"></script> -->
    <script src="libs/live2dcubismcore.min.js"></script>
    <!-- <script src="https://cdn.jsdelivr.net/gh/dylanNew/live2d/webgl/Live2D/lib/live2d.min.js"></script> -->
    <script src="libs/live2d.min.js"></script>
    <!-- <script src="https://cdn.jsdelivr.net/npm/pixi-live2d-display/dist/index.min.js"></script> -->
    <script src="libs/index.min.js"></script>
    <script src="TaskQueue.js"></script>

    <!-- Voice Activation Detection -->
    <!-- <script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.14.0/dist/ort.js"></script> -->
    <script src="libs/ort.js"></script>
    <!-- <script src="https://cdn.jsdelivr.net/npm/@ricky0123/vad-web@0.0.7/dist/bundle.min.js"></script> -->
    <script src="libs/bundle.min.js"></script>

    <link rel="stylesheet" href="index.css">
</head>

<body>
    <div class="top-left">
        <button id="wsStatus">Disconnected</button>
        <input type="text" id="wsUrl" placeholder="WebSocket URL">
    </div>

    <canvas id="canvas"></canvas>

    <div class="bottom-container">
        <div class="fixed-bottom" id="message"></div>
        <div class="control-buttons">
            <button id="micToggle">MicBtn [not implemented]</button>
            <button id="interruptBtn">InterruptLLM [not implemented]</button>
        </div>
    </div>

    <!-- <script src="./modelDict.js"></script> -->
    <script src="./live2d.js"></script>

    <script>
        let micState = false;

        let myvad;
        async function init_vad() {
            myvad = await vad.MicVAD.new({
                preSpeechPadFrames: 10,
                onSpeechStart: () => {
                    console.log("Speech start detected")
                },
                onFrameProcessed: (probs) => {
                    // console.log(`Prob:`)
                    // console.log(probs)
                },
                onSpeechEnd: (audio) => {
                    // do something with `audio` (Float32Array of audio samples at sample rate 16000)...
                    console.log(audio)
                    if (ws && ws.readyState === WebSocket.OPEN) {
                        // ws.send(JSON.stringify({ type: "mic-audio", audio: audio }));
                        micState = false;
                        sendAudioPartition(audio);
                    }
                    myvad.pause();
                }
            })
        }
        const chunkSize = 4096;
        async function sendAudioPartition(audio) {
            // semd the audio, a Float32Array of audio, to the back end by chunks
            for (let index = 0; index < audio.length; index += chunkSize) {
                const endIndex = Math.min(index + chunkSize, audio.length);
                const chunk = audio.slice(index, endIndex);
                ws.send(JSON.stringify({ type: "mic-audio", audio: chunk }));
            }
            ws.send(JSON.stringify({ type: "mic-audio-end" }));
        }


        // window.addEventListener('load', init_vad);


        // WebSocket connection
        let ws;
        const wsStatus = document.getElementById('wsStatus');
        const wsUrl = document.getElementById('wsUrl');
        const interruptBtn = document.getElementById('interruptBtn');
        const micToggle = document.getElementById('micToggle');

        wsUrl.value = "ws://127.0.0.1:8000/client-ws";
        // if running on server
        if (window.location.protocol.startsWith("http")) {
            console.log("Running on server")
            wsUrl.value = "/client-ws";
        } else { // if running on local using file://
            console.log("Running on local")
        }

        function connectWebSocket() {
            ws = new WebSocket(wsUrl.value);

            ws.onopen = function () {
                console.log("Connected to WebSocket");
                wsStatus.textContent = "Connected";
                wsStatus.classList.add('connected');
            };

            ws.onclose = function () {
                console.log("Disconnected from WebSocket");
                wsStatus.textContent = "Disconnected";
                wsStatus.classList.remove('connected');
                talkState = false;
                taskQueue.clearQueue();
            };

            ws.onmessage = function (event) {
                handleMessage(JSON.parse(event.data));
            };
        }

        wsStatus.addEventListener('click', connectWebSocket);





        function handleMessage(message) {
            console.log("Received Request: \n", message)
            switch (message.type) {
                case "full-text":
                    document.getElementById("message").textContent = message.text;
                    console.log(message)
                    console.log("full-text: ", message.text);
                case "control":
                    switch (message.text) {
                        case "start-mic":
                            start_mic();
                            break;

                        case "speaking-start":
                            if (talkState) {
                                console.log("Already talking");
                                return;
                            }
                            talkState = true;
                            stupidTalk();
                            break;
                        case "speaking-stop":
                            if (!talkState) {
                                console.log("Not talking");
                                return;
                            }
                            talkState = false;
                            break;
                    }

                    break;
                case "expression":
                    setExpression(message.text);
                    break;

                case "mouth":
                    setMouth(Number(message.text));
                    break;

                case "audio":
                    playAudioLipSync(message.audio, message.volumes, message.slice_length, test = message.text, expression_list = message.expressions);
                    break;

                case "set-model":
                    console.log("set-model: ", message.text);
                    live2dModule.init().then(() => {
                        live2dModule.loadModel(message.text);
                    });
                    break;

                case "listExpressions":
                    console.log(listSupportedExpressions());
                    break;
                default:
                    console.error("Unknown message type: " + message.type);
                    console.log(message)
            }
        }

        // set expression of the model2
        // @param {int} expressionIndex - the expression index defined in the emotionMap in modelDict.js
        function setExpression(expressionIndex) {
            expressionIndex = parseInt(expressionIndex);
            model2.internalModel.motionManager.expressionManager.setExpression(expressionIndex);
            console.info(`>> [x] -> Expression set to: (${expressionIndex})`);
        }

        // Check if the string contains an expression. If it does, set the expression of the model2.
        // @param {string} str - the string to check
        // 
        function checkStringForExpression(str) {
            console.log("emo map: ", emoMap);
            for (const key of Object.keys(emoMap)) {
                if (str.toLowerCase().includes("[" + key + "]")) {
                    console.info(">> [ ] <- add to exec queue: " + key + ", " + emoMap[key]);
                    taskQueue.addTask(() => { setExpression(emoMap[key]); });
                    taskQueue.addTask(() => { console.log("timing out...") });
                    // setExpression(emoMap[key]);
                }
            }
        }

        function listSupportedExpressions() {
            emoMap = model2.internalModel.motionManager.expressionManager.emotionMap;
            console.log(emoMap);
        }



        let talkState = false;

        /**
         * Initiates an animation that simulates talking by opening and closing the mouth randomly. This function uses a recursive timeout to continuously toggle the mouth's open state at random intervals, simulating random talking movements.
         */
        function stupidTalk() {
            let isOpen = false; // track if mouth is open

            function toggleMouth() {
                if (!talkState) {
                    model2.internalModel.coreModel.setParamFloat('PARAM_MOUTH_OPEN_Y', 0.1);
                    return;
                }

                if (isOpen) {
                    model2.internalModel.coreModel.setParamFloat('PARAM_MOUTH_OPEN_Y', 0.1);
                    isOpen = false;
                } else {
                    model2.internalModel.coreModel.setParamFloat('PARAM_MOUTH_OPEN_Y', (Math.random() * (0.9 - 0.5) + 0.5));
                    isOpen = true;
                }

                setTimeout(toggleMouth, (Math.random() * (0.2 - 0.1) + 0.1) * 1000);
            }

            toggleMouth();
        }

        function setMouth(mouthY) {
            if (typeof model2.internalModel.coreModel.setParameterValueById === 'function') {
                model2.internalModel.coreModel.setParameterValueById('ParamMouthOpenY', mouthY);
            } else {
                model2.internalModel.coreModel.setParamFloat('PARAM_MOUTH_OPEN_Y', mouthY);
            }
            // model2.internalModel.coreModel.setParameterValueById('ParamMouthOpenY', mouthY);
        }



        function playAudioLipSync(audio_base64, volumes, slice_length, text = null, expression_list = null) {

            if (text) {
                document.getElementById("message").textContent = text;
            }

            const audio = new Audio("data:audio/wav;base64," + audio_base64);
            audio.play();

            let i = 0;
            const interval = setInterval(() => {
                if (i >= volumes.length) {
                    clearInterval(interval);
                    return;
                }
                setMouth(volumes[i]);
                i++;
            }, slice_length);

            if (expression_list) {
                for (const expression of expression_list) {
                    taskQueue.addTask(() => { setExpression(expression); });
                }
            }


        }


        // Start the microphone. This will start the VAD and send audio to the server when speech is detected.
        // Once speech ends, the mic will pause.
        async function start_mic() {
            await init_vad();
            micState = true;
            console.log("Mic start ")
            myvad.start();
        }





        interruptBtn.addEventListener('click', function () {
            if (ws && ws.readyState === WebSocket.OPEN) {
                // placeholder for interrupting the LLM
                ws.send(JSON.stringify({ type: "interrupt" }));
            }
        });

        let micToggleState = false;
        micToggle.addEventListener('click', function () {
            micToggleState = !micToggleState;
            micToggle.textContent = micToggleState ? "Mic on [Not implemented]" : "Mic off [Not implemented]";

            if (micToggleState) {
                if (micState) {
                    start_mic();
                }
            } else {
                myvad = null;
            }

            if (ws && ws.readyState === WebSocket.OPEN) {
                ws.send(JSON.stringify({ type: "mic_toggle", state: micToggleState }));
            }
        });

        // Initialize WebSocket connection
        connectWebSocket();
    </script>
</body>

</html>